Skip to content

refactor(resolver): return lists of matching versions#975

Merged
LalatenduMohanty merged 3 commits intopython-wheel-build:mainfrom
rd4398:resolver-return-multiple-values
Apr 3, 2026
Merged

refactor(resolver): return lists of matching versions#975
LalatenduMohanty merged 3 commits intopython-wheel-build:mainfrom
rd4398:resolver-return-multiple-values

Conversation

@rd4398
Copy link
Copy Markdown
Contributor

@rd4398 rd4398 commented Mar 23, 2026

This commit introduces new *_all() functions that return all matching versions while
keeping existing functions unchanged for backward compatibility.

New functions return list[tuple[str, Version]] sorted by version (highest first):

  • resolver.resolve_all()
  • sources.resolve_source_all() and default_resolve_source_all()
  • wheels.resolve_prebuilt_wheel_all()
  • RequirementResolver.resolve_all()

Existing functions now call the new *_all() functions and return the
highest version (first element), maintaining identical behavior:

  • resolver.resolve() -> tuple[str, Version]
  • sources.resolve_source() -> tuple[str, Version]
  • wheels.resolve_prebuilt_wheel() -> tuple[str, Version]
  • RequirementResolver.resolve() -> tuple[str, Version]

This PR is another step towards #878

Co-Authored-By: Claude Sonnet 4.5 noreply@anthropic.com

Signed-off-by: Rohan Devasthale rdevasth@redhat.com

@rd4398 rd4398 requested a review from a team as a code owner March 23, 2026 19:22
@rd4398 rd4398 changed the title refactor(resolver): return lists of matching versions for multi-versi… refactor(resolver): return lists of matching versions Mar 23, 2026
@mergify mergify bot added the ci label Mar 23, 2026
@rd4398 rd4398 requested review from EmilienM and dhellmann March 23, 2026 19:23
@rd4398 rd4398 marked this pull request as draft March 23, 2026 19:31
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 31d3717 to 8fe05f7 Compare March 23, 2026 19:42
@rd4398 rd4398 marked this pull request as ready for review March 23, 2026 19:56
@tiran
Copy link
Copy Markdown
Collaborator

tiran commented Mar 24, 2026

The PR changes a lot of code and introduces an API-incompatible change.

Alternative:

  • introduce a new function that returns a list of candidates
  • change the existing functions to call the new function and only return the first match

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from b2eaf07 to e679584 Compare March 24, 2026 18:17
@rd4398
Copy link
Copy Markdown
Contributor Author

rd4398 commented Mar 24, 2026

The PR changes a lot of code and introduces an API-incompatible change.

Alternative:

  • introduce a new function that returns a list of candidates
  • change the existing functions to call the new function and only return the first match

Okay, that makes sense. I have made changes in the latest commit as per your suggestion

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 26890f3 to 7d8965c Compare March 24, 2026 19:56
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 717620e to d5950de Compare March 25, 2026 15:13
@rd4398
Copy link
Copy Markdown
Contributor Author

rd4398 commented Mar 25, 2026

The PR changes a lot of code and introduces an API-incompatible change.

Alternative:

  • introduce a new function that returns a list of candidates
  • change the existing functions to call the new function and only return the first match

@tiran I discussed with Doug about the next steps and we agreed that following would be the steps that I will be working on:

  1. Wrap up VersionMap to look like a resolver provider
  2. Release fromager
  3. Fix builder uses of VersionMap to use resolver provider instead
    (leave the resolver.py module alone)
  4. Remove all uses of the old resolve_source plugin entry point in fromager (delete them or rewrite them to use the provider)
  5. Go back to the RequirementResolver and have it use the resolver provider API directly (use plugin to get one, invoke the right method to get a list of results)
  6. Rename BootstrapRequirementResolver

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from d5950de to 779c8ee Compare March 30, 2026 18:13
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The diff changes provider resolution from a single-result API to a multi-candidate API: resolve_from_providerfind_all_matching_from_provider returning a highest-first list of (url, Version) candidates. Callers (resolver, sources, wheels, bootstrapper) now call the batch API and select results[0] as the best match. Caches were adjusted to store candidate lists. The default_resolve_source override/entry-point and its docs were removed. Tests were updated to mock and assert the new list-shaped resolver returns.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: refactoring the resolver module to return lists of matching versions instead of single values.
Description check ✅ Passed The PR description accurately describes the changeset: introducing new *_all() functions returning lists of matching versions while preserving existing functions for backward compatibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/fromager/sources.py (1)

149-223: ⚠️ Potential issue | 🟠 Major

Normalize new resolve_source_all() plugin results.

The new hook accepts arbitrary lists, but the legacy wrapper immediately does results[0]. If a plugin returns [] or leaves candidates in source order instead of highest-first, resolve_source() either raises IndexError or picks the wrong version. Reject empty results and sort here so the old single-result API stays compatible.

Suggested fix
     if not isinstance(resolver_results, list):
         raise ValueError(
             f"expected list of (url, version) tuples, got {type(resolver_results)}"
         )
 
-    # Validate each tuple in the list
-    for _url, version in resolver_results:
+    normalized_results: list[tuple[str, Version]] = []
+    for url, version in resolver_results:
         if not isinstance(version, Version):
             raise ValueError(f"expected Version, got {type(version)}: {version}")
+        normalized_results.append((str(url), version))
 
-    return [(str(url), version) for url, version in resolver_results]
+    if not normalized_results:
+        raise ValueError(f"expected at least one source candidate for {req}")
+
+    return sorted(normalized_results, key=lambda item: item[1], reverse=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/sources.py` around lines 149 - 223, resolve_source currently
takes results from resolve_source_all and immediately indexes results[0], which
can raise IndexError or pick the wrong candidate; update resolve_source to (1)
validate that results is non-empty and raise a ValueError with a clear message
if empty, and (2) sort the list of (url, Version) tuples by Version descending
(e.g., sorted(results, key=lambda t: t[1], reverse=True)) so the highest version
is first before selecting results[0]; reference the resolve_source and
resolve_source_all functions and ensure you operate on the same tuple shape
returned by resolve_source_all.
src/fromager/requirement_resolver.py (1)

204-234: ⚠️ Potential issue | 🟠 Major

Don’t leak the mutable cache list.

The cache now stores and returns the same list object. Any caller that mutates the result from resolve_all() in place (pop, sort, append, etc.) will silently poison _resolved_requirements and change later resolutions. Copy on store/return so the cache remains internal.

Suggested fix
     cache_key = (str(req), pre_built)
-    return self._resolved_requirements.get(cache_key)
+    result = self._resolved_requirements.get(cache_key)
+    return list(result) if result is not None else None
@@
     cache_key = (str(req), pre_built)
-    self._resolved_requirements[cache_key] = result
+    self._resolved_requirements[cache_key] = list(result)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/requirement_resolver.py` around lines 204 - 234, The cached list
is returned/stored by reference, allowing callers to mutate
`_resolved_requirements` via the result from
`get_cached_resolution`/`resolve_all`; fix by making defensive copies: in the
getter (`get_cached_resolution` shown) return a shallow copy of the list (e.g.,
result.copy() or list(result)) instead of the stored list, and in
`cache_resolution` store a shallow copy of `result` into
`_resolved_requirements` (ensure you handle None safely); tuples inside the list
can remain as-is since they are immutable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fromager/resolver.py`:
- Around line 203-222: The helper must fully materialize and validate
provider.find_matches() before returning so resolve() gets a non-empty,
highest-first list: call provider.identify(req) then consume
provider.find_matches(...) into a list, catch any exception raised during
iteration and re-raise as resolvelib.resolvers.ResolverException (including
constraint via provider.constraints.get_constraint(req.name) and
provider.get_provider_description()), validate each candidate has .url and
.version, raise a ResolverException if the list is empty or any candidate lacks
those attributes, and finally sort the list by candidate.version descending
before returning [(candidate.url, candidate.version) ...]; update the code
around provider.identify and provider.find_matches accordingly.

In `@tests/test_sources.py`:
- Around line 26-33: The test only asserts that resolve_all() was called; update
test_default_download_source_from_settings to assert the actual return value of
default_resolve_source (i.e., that it returns a single best-match tuple) instead
of only checking resolve invocation—call the function under test
(default_resolve_source) with the patched dependencies and assert it returns the
expected single tuple (best candidate), using a two-candidate
resolve.return_value (e.g., two ("url", Version(...)) entries) to validate the
highest-first contract; also apply the same change to the other test block
referenced (lines 66-79) so both tests verify the wrapper's returned tuple
rather than only delegated calls.

---

Outside diff comments:
In `@src/fromager/requirement_resolver.py`:
- Around line 204-234: The cached list is returned/stored by reference, allowing
callers to mutate `_resolved_requirements` via the result from
`get_cached_resolution`/`resolve_all`; fix by making defensive copies: in the
getter (`get_cached_resolution` shown) return a shallow copy of the list (e.g.,
result.copy() or list(result)) instead of the stored list, and in
`cache_resolution` store a shallow copy of `result` into
`_resolved_requirements` (ensure you handle None safely); tuples inside the list
can remain as-is since they are immutable.

In `@src/fromager/sources.py`:
- Around line 149-223: resolve_source currently takes results from
resolve_source_all and immediately indexes results[0], which can raise
IndexError or pick the wrong candidate; update resolve_source to (1) validate
that results is non-empty and raise a ValueError with a clear message if empty,
and (2) sort the list of (url, Version) tuples by Version descending (e.g.,
sorted(results, key=lambda t: t[1], reverse=True)) so the highest version is
first before selecting results[0]; reference the resolve_source and
resolve_source_all functions and ensure you operate on the same tuple shape
returned by resolve_source_all.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9d5db5b1-3d50-43ec-9197-add3deb7be5b

📥 Commits

Reviewing files that changed from the base of the PR and between aec9c9c and 779c8ee.

📒 Files selected for processing (7)
  • src/fromager/bootstrapper.py
  • src/fromager/requirement_resolver.py
  • src/fromager/resolver.py
  • src/fromager/sources.py
  • src/fromager/wheels.py
  • tests/test_requirement_resolver.py
  • tests/test_sources.py

@rd4398 rd4398 marked this pull request as draft March 30, 2026 18:55
Replace intermediate resolution functions with direct provider usage:
- RequirementResolver now calls resolve_from_provider() directly
- sources.resolve_source() uses get_resolver_provider plugin hook
- wheels.resolve_prebuilt_wheel() uses get_resolver_provider plugin hook
- Removed resolve_all() methods per architect guidance

This refactoring simplifies the resolution architecture by eliminating
the intermediate *_all() function layer while maintaining all existing
functionality. All resolution now goes through the provider pattern
with overrides.find_and_invoke() + resolver.resolve_from_provider().

Tests updated to match new implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 2331411 to 3cd2c8e Compare March 30, 2026 18:58
Rename class and files to better reflect purpose:
- RequirementResolver → BootstrapRequirementResolver
- requirement_resolver.py → bootstrap_requirement_resolver.py
- test_requirement_resolver.py → test_bootstrap_requirement_resolver.py

The new name clarifies that this resolver is specifically for the
bootstrap process, reducing naming confusion.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from af9e37e to 40bed8a Compare March 30, 2026 19:58
@rd4398 rd4398 marked this pull request as ready for review March 30, 2026 20:08
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/fromager/resolver.py (1)

193-213: ⚠️ Potential issue | 🟠 Major

Normalize find_matches() output here.

Every caller now assumes this helper returns a non-empty highest-first list and immediately reads results[0]. A custom provider can still return a lazy or unsorted iterable, which means iteration-time failures escape the wrapper and the wrong version can be selected. Materialize inside the try, reject empty results, and sort before converting to tuples.

Suggested change
     try:
-        # Bypass resolvelib's resolver to collect all matching candidates rather than
-        # just the single best one. This is safe because get_dependencies() returns []
-        # (no transitive deps to resolve). If get_dependencies() is ever extended,
-        # this must be revisited to use resolvelib's full resolution.
-        candidates = provider.find_matches(
-            identifier=identifier,
-            requirements={identifier: [req]},
-            incompatibilities={},  # Empty - safe only because no transitive deps
-        )
+        # Bypass resolvelib's resolver to collect all matching candidates rather than
+        # just the single best one. This is safe because get_dependencies() returns []
+        # (no transitive deps to resolve). If get_dependencies() is ever extended,
+        # this must be revisited to use resolvelib's full resolution.
+        candidates = list(
+            provider.find_matches(
+                identifier=identifier,
+                requirements={identifier: [req]},
+                incompatibilities={},  # Empty - safe only because no transitive deps
+            )
+        )
     except resolvelib.resolvers.ResolverException as err:
         constraint = provider.constraints.get_constraint(req.name)
         provider_desc = provider.get_provider_description()
         original_msg = str(err)
         raise resolvelib.resolvers.ResolverException(
             f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
         ) from err
 
-    # Convert candidates to list of (url, version) tuples
-    # Candidates are already sorted by version (highest first)
+    if not candidates:
+        raise resolvelib.resolvers.ResolverException(
+            f"found no match for {req} using {provider.get_provider_description()}"
+        )
+
+    candidates = sorted(
+        candidates,
+        key=attrgetter("version", "build_tag"),
+        reverse=True,
+    )
+
     return [(candidate.url, candidate.version) for candidate in candidates]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/resolver.py` around lines 193 - 213, The provider.find_matches
call returns an iterable that must be materialized and validated inside the try
block: call provider.find_matches(...) and immediately convert to a concrete
list (e.g., candidates = list(...)), raise a ResolverException if the list is
empty (include provider.constraints.get_constraint(req.name),
provider.get_provider_description() and the original exception/message), then
sort candidates by version descending (highest-first) before converting to the
list of (candidate.url, candidate.version) tuples returned by this helper; keep
the existing exception wrapping (resolvelib.resolvers.ResolverException from
err) and ensure all materialization/sorting happens inside the try so
iteration-time errors are caught here.
src/fromager/bootstrap_requirement_resolver.py (1)

166-178: ⚠️ Potential issue | 🟠 Major

Bootstrap resolution should reuse the public resolver wrappers.

These branches reimplement provider lookup directly instead of delegating to the source/wheel resolver APIs. That skips whatever compatibility logic those wrappers carry, so bootstrap can ignore legacy single-result hooks even if the command path still honors them. Route this through the public *_all() wrappers, or share the same fallback logic here.

Also applies to: 192-204

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/bootstrap_requirement_resolver.py` around lines 166 - 178, The
bootstrap branch currently calls overrides.find_and_invoke(...) and
resolver.find_all_matching_from_provider(...) directly, bypassing the public
resolver wrapper logic; change this to use the public "*_all" wrapper functions
(the same APIs used elsewhere) or the shared fallback helper so
compatibility/legacy single-result hooks are honored: replace the
overrides.find_and_invoke(...) +
resolver.find_all_matching_from_provider(provider, req) sequence with the
corresponding public resolver_all(...) call (or the shared fallback call used by
other paths) that accepts the same parameters
(resolver.default_resolver_provider, ctx=self.ctx, req=req,
include_sdists=False, include_wheels=True, sdist_server_url=url,
req_type=req_type, ignore_platform=False), and make the identical adjustment in
the other branch referenced (lines handling the same logic around 192-204).
src/fromager/sources.py (1)

149-167: ⚠️ Potential issue | 🟠 Major

Legacy resolve_source overrides are bypassed here.

This path only looks for get_resolver_provider, so existing package overrides that still implement the single-result resolve_source hook are never called. get_source_type() still treats resolve_source as a valid override, so the command path and metadata can now disagree. Keep resolve_source() behind a compatibility layer (resolve_source_all() plus legacy-hook fallback) instead of requiring all plugins to migrate at once.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/sources.py` around lines 149 - 167, This code only calls the
"get_resolver_provider" hook via overrides.find_and_invoke and then
resolver.find_all_matching_from_provider, which bypasses legacy single-result
plugins that implement "resolve_source"; change the logic to fall back to the
legacy hook: after attempting overrides.find_and_invoke(...,
"get_resolver_provider", ...) and calling
resolver.find_all_matching_from_provider(provider, req), if no provider was
returned or results is empty, call overrides.find_and_invoke(req.name,
"resolve_source", None, ctx=ctx, req=req, req_type=req_type) (or a new
compatibility wrapper like "resolve_source_all" that normalizes output) and
convert that single-result response into the (url, version) tuple expected by
the rest of the code before returning; use the same ctx/req/req_type args and
preserve the final return as str(url), version.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fromager/bootstrap_requirement_resolver.py`:
- Around line 181-185: The current bare "except Exception: continue" in the loop
that queries wheel_server_urls (around the block that raises ValueError(f"Could
not find a prebuilt wheel for {req} on {' or '.join(wheel_server_urls)}")) hides
real failures; change it to only swallow the specific "no matching wheel"
sentinel/error returned by the provider (e.g., a custom NoWheelFound/NotFound
exception or a well-defined response check for 404/no-match) and re-raise all
other exceptions (auth errors, 5xx, runtime plugin errors) so they surface
immediately; locate the try/except around req and wheel_server_urls and replace
the broad catch with targeted exception handling or explicit response checks to
continue only on expected miss/no-match conditions.

In `@tests/test_resolver.py`:
- Around line 1254-1256: The test currently bypasses resolver.resolve by calling
find_all_matching_from_provider(provider, Requirement("test-package==1.0.0"));
change the test to call resolver.resolve(...) with the same requirement so
resolver's override lookup and error paths are exercised, and instead of
constructing the provider directly patch/mock the override selection (the
function or method used by resolver to pick overrides) to return the intended
provider; keep references to resolver.resolve, find_all_matching_from_provider,
Requirement("test-package==1.0.0") and the provider object so you locate and
replace the direct call with a resolve invocation and a patch of the override
selection behavior.

---

Duplicate comments:
In `@src/fromager/bootstrap_requirement_resolver.py`:
- Around line 166-178: The bootstrap branch currently calls
overrides.find_and_invoke(...) and resolver.find_all_matching_from_provider(...)
directly, bypassing the public resolver wrapper logic; change this to use the
public "*_all" wrapper functions (the same APIs used elsewhere) or the shared
fallback helper so compatibility/legacy single-result hooks are honored: replace
the overrides.find_and_invoke(...) +
resolver.find_all_matching_from_provider(provider, req) sequence with the
corresponding public resolver_all(...) call (or the shared fallback call used by
other paths) that accepts the same parameters
(resolver.default_resolver_provider, ctx=self.ctx, req=req,
include_sdists=False, include_wheels=True, sdist_server_url=url,
req_type=req_type, ignore_platform=False), and make the identical adjustment in
the other branch referenced (lines handling the same logic around 192-204).

In `@src/fromager/resolver.py`:
- Around line 193-213: The provider.find_matches call returns an iterable that
must be materialized and validated inside the try block: call
provider.find_matches(...) and immediately convert to a concrete list (e.g.,
candidates = list(...)), raise a ResolverException if the list is empty (include
provider.constraints.get_constraint(req.name),
provider.get_provider_description() and the original exception/message), then
sort candidates by version descending (highest-first) before converting to the
list of (candidate.url, candidate.version) tuples returned by this helper; keep
the existing exception wrapping (resolvelib.resolvers.ResolverException from
err) and ensure all materialization/sorting happens inside the try so
iteration-time errors are caught here.

In `@src/fromager/sources.py`:
- Around line 149-167: This code only calls the "get_resolver_provider" hook via
overrides.find_and_invoke and then resolver.find_all_matching_from_provider,
which bypasses legacy single-result plugins that implement "resolve_source";
change the logic to fall back to the legacy hook: after attempting
overrides.find_and_invoke(..., "get_resolver_provider", ...) and calling
resolver.find_all_matching_from_provider(provider, req), if no provider was
returned or results is empty, call overrides.find_and_invoke(req.name,
"resolve_source", None, ctx=ctx, req=req, req_type=req_type) (or a new
compatibility wrapper like "resolve_source_all" that normalizes output) and
convert that single-result response into the (url, version) tuple expected by
the rest of the code before returning; use the same ctx/req/req_type args and
preserve the final return as str(url), version.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 82df2c50-841f-41cd-bb10-ffc5b21d5405

📥 Commits

Reviewing files that changed from the base of the PR and between 779c8ee and 40bed8a.

📒 Files selected for processing (10)
  • docs/reference/hooks.rst
  • pyproject.toml
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/bootstrapper.py
  • src/fromager/resolver.py
  • src/fromager/sources.py
  • src/fromager/wheels.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_resolver.py
  • tests/test_sources.py
💤 Files with no reviewable changes (2)
  • docs/reference/hooks.rst
  • pyproject.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/fromager/wheels.py

@rd4398 rd4398 requested a review from ryanpetrello March 30, 2026 20:27
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 423d5c0 to 6b06299 Compare April 1, 2026 17:54
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/fromager/sources.py (1)

169-175: ⚠️ Potential issue | 🟠 Major

Exception handler won't catch ResolverException raised by find_all_matching_from_provider.

find_all_matching_from_provider raises resolvelib.resolvers.ResolverException at line 207-209 when provider.find_matches() fails, but this handler only catches specific subclasses. The base exception bypasses the debug log and propagates unhandled.

Proposed fix
     except (
         resolvelib.InconsistentCandidate,
         resolvelib.RequirementsConflicted,
         resolvelib.ResolutionImpossible,
+        resolvelib.resolvers.ResolverException,
     ) as err:
         logger.debug(f"could not resolve {req} with {constraint}: {err}")
         raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/sources.py` around lines 169 - 175, The current except block in
find_all_matching_from_provider only catches specific resolvelib subclasses
(InconsistentCandidate, RequirementsConflicted, ResolutionImpossible) so it
misses resolvelib.resolvers.ResolverException raised by provider.find_matches();
update the exception handling in the same function to also catch
resolvelib.resolvers.ResolverException (either by adding it to the existing
except tuple or adding a separate except resolvelib.resolvers.ResolverException
as err) and ensure you call logger.debug with the same message (e.g., f"could
not resolve {req} with {constraint}: {err}") before re-raising the exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/fromager/sources.py`:
- Around line 169-175: The current except block in
find_all_matching_from_provider only catches specific resolvelib subclasses
(InconsistentCandidate, RequirementsConflicted, ResolutionImpossible) so it
misses resolvelib.resolvers.ResolverException raised by provider.find_matches();
update the exception handling in the same function to also catch
resolvelib.resolvers.ResolverException (either by adding it to the existing
except tuple or adding a separate except resolvelib.resolvers.ResolverException
as err) and ensure you call logger.debug with the same message (e.g., f"could
not resolve {req} with {constraint}: {err}") before re-raising the exception.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fe711774-a8e4-4414-942e-90084a9c02a4

📥 Commits

Reviewing files that changed from the base of the PR and between 423d5c0 and 6b06299.

📒 Files selected for processing (9)
  • docs/reference/hooks.rst
  • pyproject.toml
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/resolver.py
  • src/fromager/sources.py
  • src/fromager/wheels.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_resolver.py
  • tests/test_sources.py
💤 Files with no reviewable changes (2)
  • docs/reference/hooks.rst
  • pyproject.toml
✅ Files skipped from review due to trivial changes (1)
  • tests/test_bootstrap_requirement_resolver.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/test_resolver.py
  • src/fromager/wheels.py
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/resolver.py
  • tests/test_sources.py

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 51e8279 to 7b874a4 Compare April 1, 2026 18:05
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/fromager/wheels.py (1)

492-497: Unreachable branch: find_all_matching_from_provider() raises on empty results.

BaseProvider.find_matches() raises ResolverException when no candidates match (resolver.py line 624-626), so results is guaranteed non-empty if no exception was thrown. The else branch at lines 496-497 is dead code.

Either remove the branch or add a comment explaining it's defensive:

Option 1: Remove dead code
             # Get all matching candidates from provider
             results = resolver.find_all_matching_from_provider(provider, req)
-
-            if results:
-                # Return highest version (first in sorted list)
-                wheel_url, version = results[0]
-                return str(wheel_url), version
-            else:
-                excs.append(ValueError(f"no results for {url}: {results=}"))
+            # Return highest version (first in sorted list)
+            wheel_url, version = results[0]
+            return wheel_url, version
Option 2: Add defensive comment
             if results:
                 # Return highest version (first in sorted list)
                 wheel_url, version = results[0]
-                return str(wheel_url), version
+                return wheel_url, version
             else:
+                # Defensive: find_all_matching_from_provider raises if no matches,
+                # but guard against unexpected empty returns from custom providers
                 excs.append(ValueError(f"no results for {url}: {results=}"))

Also, str(wheel_url) is redundant—wheel_url is already str per the return type list[tuple[str, Version]].

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/wheels.py` around lines 492 - 497, Remove the dead else branch
and the redundant str() conversion: since BaseProvider.find_matches() (and thus
find_all_matching_from_provider()) raises when there are no matches, the "else:
excs.append(...)" branch is unreachable—delete that branch and simplify the
return to return wheel_url, version (drop str(wheel_url)). Update the code in
wheels.py where results is handled (the block that assigns wheel_url, version
from results[0] and appends to excs) to reflect this change; if you prefer to
keep defensive intent instead, replace the else branch with a brief comment
stating it's unreachable due to BaseProvider.find_matches() raising on empty
results and still remove the str() call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/fromager/wheels.py`:
- Around line 492-497: Remove the dead else branch and the redundant str()
conversion: since BaseProvider.find_matches() (and thus
find_all_matching_from_provider()) raises when there are no matches, the "else:
excs.append(...)" branch is unreachable—delete that branch and simplify the
return to return wheel_url, version (drop str(wheel_url)). Update the code in
wheels.py where results is handled (the block that assigns wheel_url, version
from results[0] and appends to excs) to reflect this change; if you prefer to
keep defensive intent instead, replace the else branch with a brief comment
stating it's unreachable due to BaseProvider.find_matches() raising on empty
results and still remove the str() call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ed066713-ad67-43e3-bab8-0dc6d274f4bf

📥 Commits

Reviewing files that changed from the base of the PR and between 6b06299 and 7b874a4.

📒 Files selected for processing (9)
  • docs/reference/hooks.rst
  • pyproject.toml
  • src/fromager/bootstrap_requirement_resolver.py
  • src/fromager/resolver.py
  • src/fromager/sources.py
  • src/fromager/wheels.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_resolver.py
  • tests/test_sources.py
💤 Files with no reviewable changes (2)
  • docs/reference/hooks.rst
  • pyproject.toml
✅ Files skipped from review due to trivial changes (3)
  • tests/test_resolver.py
  • tests/test_bootstrap_requirement_resolver.py
  • tests/test_sources.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/fromager/sources.py
  • src/fromager/bootstrap_requirement_resolver.py

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from d27119c to e696bc8 Compare April 2, 2026 17:45
Copy link
Copy Markdown
Member

@LalatenduMohanty LalatenduMohanty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rd4398 The PR looks good, but need some minor cleanups.

BaseProvider.find_matches() raises ResolverException when nothing matches — it never returns an empty list (resolver.py L624-626). So find_all_matching_from_provider()
always returns a non-empty list or raises.

The if results: / else: checks in bootstrap_requirement_resolver.py:177 and wheels.py:492 are dead code — the else branch can never execute. The "not found" case is already handled by the except ResolverException blocks.

bootstrap_requirement_resolver.py:177-182:

  results = resolver.find_all_matching_from_provider(provider, req)                                                                                                          
  if results:       # always True                                                                                                                                            
      return results                                                                                                                                                         
  else:             # never reached — find_matches raises instead of returning []                                                                                            
      logger.debug(f"{req.name}: no prebuilt wheel found on {url}, trying next server")                                                                                      ``` 

wheels.py:492-497:

  if results:       # always True                                                                                                                                            
      wheel_url, version = results[0]                                                                                                                                        
      return str(wheel_url), version                                                                                                                                         
  else:             # never reached                                                                                                                                          
      excs.append(ValueError(f"no results for {url}: {results=}"))                                                                                                           

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 9953c22 to f5d962b Compare April 3, 2026 13:06
@rd4398
Copy link
Copy Markdown
Contributor Author

rd4398 commented Apr 3, 2026

@rd4398 The PR looks good, but need some minor cleanups.

BaseProvider.find_matches() raises ResolverException when nothing matches — it never returns an empty list (resolver.py L624-626). So find_all_matching_from_provider() always returns a non-empty list or raises.

The if results: / else: checks in bootstrap_requirement_resolver.py:177 and wheels.py:492 are dead code — the else branch can never execute. The "not found" case is already handled by the except ResolverException blocks.

bootstrap_requirement_resolver.py:177-182:

  results = resolver.find_all_matching_from_provider(provider, req)                                                                                                          
  if results:       # always True                                                                                                                                            
      return results                                                                                                                                                         
  else:             # never reached — find_matches raises instead of returning []                                                                                            
      logger.debug(f"{req.name}: no prebuilt wheel found on {url}, trying next server")                                                                                      ``` 

wheels.py:492-497:

  if results:       # always True                                                                                                                                            
      wheel_url, version = results[0]                                                                                                                                        
      return str(wheel_url), version                                                                                                                                         
  else:             # never reached                                                                                                                                          
      excs.append(ValueError(f"no results for {url}: {results=}"))                                                                                                           

@LalatenduMohanty I agree. I have made changes in latest commit. I think this is ready to land.

@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch 3 times, most recently from ca16167 to 2069ff9 Compare April 3, 2026 16:02
Copy link
Copy Markdown
Member

@LalatenduMohanty LalatenduMohanty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the commit message contains a unwanted text at the end .i.e. # and raised ValueError with no exception details lets fix it before we merge the code. Apart from it , the PR LGTM

commit 2069ff9da5baddb05e34c83ddf6ab106b1f913ec (HEAD -> resolver-return-multiple-values)
Author: Rohan Devasthale <rdevasth@redhat.com>
Date:   Mon Mar 30 15:22:34 2026 -0400

    docs: remove deprecated resolve_source hook
    
    Remove the resolve_source plugin entry point and its documentation:
    - Removed entry point from pyproject.toml
    - Removed default_resolve_source from hooks.rst
    
    This hook was replaced by the get_resolver_provider hook during the
    resolver refactoring. Resolution now uses the provider pattern directly
    instead of calling resolve_source as a plugin hook.
    
    Fixes ReadTheDocs CI warning about missing default_resolve_source.
    
    Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
    Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
    
     # and raised ValueError with no exception details

Remove the resolve_source plugin entry point and its documentation:
- Removed entry point from pyproject.toml
- Removed default_resolve_source from hooks.rst

This hook was replaced by the get_resolver_provider hook during the
resolver refactoring. Resolution now uses the provider pattern directly
instead of calling resolve_source as a plugin hook.

Fixes ReadTheDocs CI warning about missing default_resolve_source.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
@rd4398 rd4398 force-pushed the resolver-return-multiple-values branch from 2069ff9 to 179c096 Compare April 3, 2026 16:22
@rd4398
Copy link
Copy Markdown
Contributor Author

rd4398 commented Apr 3, 2026

One of the commit message contains a unwanted text at the end .i.e. # and raised ValueError with no exception details lets fix it before we merge the code. Apart from it , the PR LGTM

commit 2069ff9da5baddb05e34c83ddf6ab106b1f913ec (HEAD -> resolver-return-multiple-values)
Author: Rohan Devasthale <rdevasth@redhat.com>
Date:   Mon Mar 30 15:22:34 2026 -0400

    docs: remove deprecated resolve_source hook
    
    Remove the resolve_source plugin entry point and its documentation:
    - Removed entry point from pyproject.toml
    - Removed default_resolve_source from hooks.rst
    
    This hook was replaced by the get_resolver_provider hook during the
    resolver refactoring. Resolution now uses the provider pattern directly
    instead of calling resolve_source as a plugin hook.
    
    Fixes ReadTheDocs CI warning about missing default_resolve_source.
    
    Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
    Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
    
     # and raised ValueError with no exception details

fixed

@LalatenduMohanty
Copy link
Copy Markdown
Member

@mergify queue

@mergify
Copy link
Copy Markdown
Contributor

mergify bot commented Apr 3, 2026

queue

⚠️ Configuration not compatible with a branch protection setting

Details

The branch protection setting Require branches to be up to date before merging is not compatible with draft PR checks. To keep this branch protection enabled, update your Mergify configuration to enable in-place checks: set merge_queue.max_parallel_checks: 1, set every queue rule batch_size: 1, and avoid two-step CI (make merge_conditions identical to queue_conditions). Otherwise, disable this branch protection.

@LalatenduMohanty LalatenduMohanty merged commit e3af66e into python-wheel-build:main Apr 3, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants